Skip to content

fix: keep sibling keywords when a referenced schema has a root-level $ref - #2965

Open
RomanHotsiy wants to merge 24 commits into
mainfrom
fix/bundle-root-ref-sibling-keywords
Open

fix: keep sibling keywords when a referenced schema has a root-level $ref#2965
RomanHotsiy wants to merge 24 commits into
mainfrom
fix/bundle-root-ref-sibling-keywords

Conversation

@RomanHotsiy

@RomanHotsiy RomanHotsiy commented Jul 24, 2026

Copy link
Copy Markdown
Member

What/Why/How?

bundle dropped the sibling keywords of a root-level $ref when the referenced schema itself started with a $ref — for example an external schema file that composes another file ($ref: ./BaseProblem.yaml plus title/type/properties/required).
The bundled output collapsed such schemas to a bare $ref pointing at the end of the chain, silently losing the sibling constraints.
In OpenAPI 3.1, Schema Objects follow JSON Schema draft 2020-12, where $ref with siblings is a composition, not a transparent alias.

Root cause: followRef in resolve.ts chased every chained $ref to the end of the chain, discarding the intermediate nodes together with their sibling keys.

The fix — resolution results now carry the composition as an additive chain field, so ctx.resolve() keeps returning the chain-end node exactly as before and nothing changes for existing rules, decorators, or plugins:

  • resolve.tsfollowRef still chases to the end of the chain, but records every composed $ref it passes through as a chain hop (ResolvedRefChainHop) on the resolve result.
  • walk.ts — the walker walks each chain hop as a ref node from its own location, so hop siblings and their inner $refs are processed; ResolveResult exposes the chain (with Locations) to visitors.
  • bundle-visitor.ts — a chained ref is rewritten to point at the first composed hop (saved as a component with siblings intact) instead of the chain end; the hop's own $ref is rewritten by the walker pass. Discriminator mappings and component-name dedup use the same effective target.
  • no-required-schema-properties-undefined.ts — the one rule that needs the composition: it validates required on chain hops (they are never visited as Schema nodes) and follows chains and sibling properties/allOf/anyOf/oneOf when looking up required properties.
  • rules/utils.tsresolveSchema passes the chain through for chain-aware rules.

Because resolve() behavior is unchanged for consumers, the rule regressions found on the earlier revision of this PR (array-parameter-serialization, struct, discriminator checks) cannot occur — verified explicitly for each.
The remove-unused-components decorators are chain-safe by construction (they build their usage graph from raw $ref pointers), covered by a new test.

Composed schemas are now preserved as components with their sibling keywords intact, references keep pointing at the composed schema, and --dereferenced merges the composition in chain order.

Reference

Fixes #2964

Testing

  • Unit tests (written to fail first on the broken behavior): bundle tests for external-file and internal component chains plus --remove-unused-components, a resolver test asserting the recorded chain, and regression tests for struct, no-required-schema-properties-undefined (sibling-defined, chain-defined, allOf-sibling-defined, and genuinely-missing required properties), and no-unused-components.
  • Two e2e bundle tests: sibling-refs-root-external-file (fails on unfixed code) and sibling-refs-root-in-file.
  • Full unit suite (2031 tests) and e2e suite pass; no existing snapshots changed. Lint warning count unchanged.
  • Verified by hand: bundle, bundle --dereferenced, lint on the issue repro, and the rule-regression repros from the review.

Check yourself

  • This PR follows the contributing guide
  • All new/updated code is covered by tests
  • Core code changed? - Tested with other Redocly products (internal contributions only)
  • New package installed? - Tested in different environments (browser/node)
  • Documentation update has been considered

Security

  • The security impact of the change has been considered
  • Code follows company security practices and guidelines

🤖 Generated with Claude Code


Note

Medium Risk
Touches core ref resolution, document walking, and bundling—widely used paths—with behavior changes only when $ref has sibling keys; coverage is extensive but regressions in edge-case ref chains are possible.

Overview
Fixes bundle dropping JSON Schema 2020-12 style compositions where a schema has $ref plus sibling keywords (title, properties, required, etc.) when resolution walked through another root-level $ref (e.g. external BadRequest.yaml that $refs BaseProblem and adds fields).

Resolution still returns the chain-end node, but now attaches a chain of composed hops (ResolvedRefChainHop) for each $ref with extra keys. walk.ts walks those hops and sibling keys at the ref’s location; bundle-visitor.ts saves components from the first hop (effectiveTarget) so bundled output keeps the composed shape. replaceRef merges siblings with the resolved object without losing non-object targets.

Lint rules that need composition context were updated: no-required-schema-properties-undefined follows chain hops and validates required on ref nodes; struct / no-unused-components regressions are covered. New unit, rule, and e2e fixtures exercise external files, multi-hop chains, discriminator mappings, sibling key collisions, and --remove-unused-components.

Reviewed by Cursor Bugbot for commit c166c21. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c166c21

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@redocly/openapi-core Minor
@redocly/cli Minor
@redocly/client-generator Patch
@redocly/respect-core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 77% (🎯 77%) 11419 / 14828
🔵 Statements 77.12% (🎯 77%) 12206 / 15826
🔵 Functions 81.12% (🎯 81%) 2304 / 2840
🔵 Branches 70.73% (🎯 70%) 8365 / 11825
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/ref-utils.ts 95.23% 96.15% 100% 97.43% 10, 58
packages/core/src/resolve.ts 97.19% 96.47% 100% 97.1% 96, 143, 299, 492-493
packages/core/src/walk.ts 98.23% 97.31% 85% 98.74% 217, 232, 459
packages/core/src/bundle/bundle-visitor.ts 74% 69.62% 100% 74% 38, 42-50, 61-65, 72, 81, 89, 94-119, 199-211, 228-229, 247-248, 266-267, 309
packages/core/src/rules/utils.ts 92.68% 89.7% 100% 91.78% 73, 107, 117-121, 137, 148-152, 254
packages/core/src/rules/common/no-required-schema-properties-undefined.ts 96.92% 94.91% 100% 96.55% 51, 87
Generated in workflow #11079 for commit c166c21 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Performance Benchmark (Lower is Faster)

CLI Version Bundle Lint Check Config
cli-latest ▓▓▓ 1.09x ± 0.01 ▓ 1.02x ± 0.01 ▓▓ 1.04x ± 0.01
cli-next ▓ 1.00x (Fastest) ▓ 1.00x (Fastest) ▓ 1.00x (Fastest)

Comment thread .changeset/root-ref-sibling-keywords.md Outdated
@RomanHotsiy
RomanHotsiy marked this pull request as ready for review July 24, 2026 07:28
@RomanHotsiy
RomanHotsiy requested review from a team as code owners July 24, 2026 07:28
Comment thread packages/core/src/rules/common/no-required-schema-properties-undefined.ts Outdated

@bencagri bencagri left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Comment thread .changeset/root-ref-sibling-keywords.md Outdated
Comment thread packages/core/src/rules/common/no-required-schema-properties-undefined.ts Outdated
Comment thread packages/core/src/resolve.ts Outdated
Comment thread packages/core/src/walk.ts
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/resolve.ts
Comment thread packages/core/src/bundle/bundle-visitor.ts Outdated
Comment thread packages/core/src/bundle/bundle-visitor.ts
Comment thread tests/e2e/bundle/sibling-refs-root-in-file/openapi.yaml
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/resolve.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
Comment thread .changeset/root-ref-sibling-keywords.md Outdated
@tatomyr

tatomyr commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator
image

Nice performance gain. Do you know the reason?

@RomanHotsiy

Copy link
Copy Markdown
Member Author

Verified locally and bisected it — the gain is real and comes from one commit: 094ff48, the sibling-collision fix in walk.ts. Bundling Rebilly's api-definitions: main 645.9ms ± 3.8 → after the chain rework (2cb6857) 651.0ms ± 5.1 (no change) → after the props-loop restructure (094ff48) 578.1ms ± 3.1, HEAD 573.7ms ± 2.9 (~11%, matching the 1.10x here). The mechanism: the old loop ran the scalar-sibling special case — isRef(node[propName]) plus a fallback property read — for every property of every object node in the document. The restructured loop gates all $ref-sibling handling behind nodeIsRef, which is false for almost every node, so those per-property checks dropped off the hot path entirely. So the correctness fix for colliding sibling keys happened to also delete the walker's most-executed redundant check.

🤖 Addressed by Claude Code

Comment thread packages/core/src/walk.ts
Comment thread packages/core/src/bundle/bundle-visitor.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/walk.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6fbd859. Configure here.

Comment thread packages/core/src/walk.ts
@RomanHotsiy

Copy link
Copy Markdown
Member Author

Okay, now it's ready to reveiw.

@RomanHotsiy
RomanHotsiy requested review from tatomyr and vadyvas July 28, 2026 08:57

@vadyvas vadyvas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One stylistic thing, non-blocking: the new code more comments than these files usually are, and a few of the comments are hard to read in themselves

Comment thread packages/core/src/bundle/bundle-visitor.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/__tests__/bundle.test.ts Outdated
Comment thread packages/core/src/ref-utils.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
@RomanHotsiy
RomanHotsiy requested a review from vadyvas August 3, 2026 10:04
Comment thread packages/core/src/walk.ts
} = resolve(node);
const enteredContexts: Set<VisitorLevelContext> = new Set();

if (isRefWithSiblings(node)) {

@vadyvas vadyvas Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found issue with node order in api description:
ref visitors fire twice on a composed $ref, and whether it's once or twice depends on key order.

To reproduce:

// plugin.cjs
module.exports = () => ({
  id: 'probe',
  rules: {
    oas3: {
      'count-ref-visits': () => ({
        ref(node, ctx) {
          if (typeof node.$ref === 'string' && Object.keys(node).length > 1) {
            ctx.report({ message: `ref visit: ${ctx.location.pointer}` });
          }
        },
      }),
    },
  },
});
# redocly.yaml
plugins: [./plugin.cjs]
extends: []
rules:
  probe/count-ref-visits: error
# openapi.yaml
openapi: 3.1.0
info: { title: t, version: 1.0.0 }
paths: {}
components:
  schemas:
    Usage:
      $ref: '#/components/schemas/Composed'
    Composed:
      $ref: '#/components/schemas/Base'
      title: Composed
    Base:
      type: object
openapi: 3.1.0
info: { title: t, version: 1.0.0 }
paths: {}
components:
  schemas:
    # Same three schemas, only reordered: the tree walk reaches Composed first.
    Composed:
      $ref: '#/components/schemas/Base'
      title: Composed
    Base:
      type: object
    Usage:
      $ref: '#/components/schemas/Composed'

lint reports Composed twice. Move Usage below Base - reports once. On main it's always once

Comment thread packages/core/src/walk.ts

if (resolvedNode !== undefined && resolvedLocation && type.name !== 'scalar') {
// the isRef check narrows `node` to OasRef, but sibling keys are read from it too
const rawNode = node as Record<string, unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we still need it after adding isRefWithSiblings ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bundle drops sibling keywords when an external schema file has a root-level $ref

6 participants